Skip to content

Admin nav refactor#7

Merged
rsantacroce merged 3 commits into
LayerTwo-Labs:mainfrom
rsantacroce:admin-nav-refactor
Jul 22, 2026
Merged

Admin nav refactor#7
rsantacroce merged 3 commits into
LayerTwo-Labs:mainfrom
rsantacroce:admin-nav-refactor

Conversation

@rsantacroce

Copy link
Copy Markdown
Collaborator

No description provided.

Turns the read-only admin dashboard into an actual operator control
surface. Four buttons on /admin, each POSTing to a CSRF-gated route
that redirects back with a flash message:

  1. Deposit BTC → Thunder — shells out to grpcurl for the enforcer's
     cusf.mainchain.v1.WalletService/CreateDepositTransaction, then
     writes a row into the pool DB's deposits table. Assumes the
     enforcer wallet already holds spendable BTC UTXOs (operator
     pre-funds manually with bitcoin-cli sendtoaddress).
  2. Trigger payout cycle now — POSTs to a new admin HTTP surface
     added to the payout worker; runs runOnce() synchronously and
     returns the tick result.
  3. Remove stale Thunder tx — form with txid input, calls Thunder's
     remove_from_mempool RPC. Fixes the class of stuck states where
     a phantom tx pins the reserve UTXO and every subsequent transfer
     hits "utxo double spent".
  4. Nudge Thunder mine — POSTs Thunder's mine RPC. Same idempotent
     nudge the systemd timer runs, exposed for manual use.

Security:
 - CSRF: per-render random token, single-use, 1h TTL, in-memory store.
   HTTP Basic auth alone doesn't defend against a malicious cross-site
   POST that piggybacks the cached auth header — the CSRF token does.
 - Payout worker admin HTTP is loopback-bound by default and unauth'd;
   the loopback bind is the boundary.
 - Dashboard now writes to the deposits table, so the systemd unit
   loses ReadOnlyPaths=@root@ in favour of ReadWritePaths=@root@/data.

Deploy:
 - New env: PAYOUT_ADMIN_URL, ENFORCER_GRPC_ADDR, GRPCURL_BIN,
   THUNDER_SIDECHAIN_ID on the dashboard; PAYOUT_ADMIN_BIND,
   PAYOUT_ADMIN_PORT on the payout worker.
 - Docker dashboard image now bakes grpcurl (v1.9.1, multi-arch via
   TARGETARCH). Compose publishes payout admin port on 127.0.0.1 so
   the dashboard container can reach it via host.docker.internal.
 - Systemd units + docker-compose.yml + .env.example all updated.

Smoke-tested locally: payout admin HTTP returns clean JSON on
/healthz and /tick; dashboard renders 4 forms with fresh CSRF tokens;
POST without token / with a fake token gets rejected with a flash;
POST with a valid token executes the action (nudge-mine correctly
reports "fetch failed" when Thunder RPC is unreachable).
Two follow-ups after the first deploy of the write actions:

1. Deposit action's DB log crashed with "db.prepare is not a function".
   Root causes:
     - openDb() returns a lazy WRAPPER, not a raw better-sqlite3 handle;
       we need to call .get() first to unwrap.
     - The dashboard's existing handle is opened readonly:true, so even
       after unwrap the INSERT into `deposits` would fail.
   Fix: openDb now accepts { readonly } (default true, unchanged for the
   existing read path); server.js opens a second writable handle just for
   admin write actions and hands the unwrapped result to createDeposit.
   Also hardens createDeposit to skip the DB log gracefully if the
   handle is missing rather than throwing.

2. Operator asked for visibility into how much BTC is available to
   deposit. New enforcerBalance() probe shells out to grpcurl for
   cusf.mainchain.v1.WalletService/GetBalance (returns confirmed +
   pending sats + hasSynced). Rendered as a new "Pool BTC (enforcer
   wallet)" card next to the Thunder reserve card, and echoed inline
   inside the deposit form ("Spendable now: N sats") so the operator
   knows what they can spend without leaving the page.

Verified locally: syntax clean, gRPC probe against the live enforcer
returns confirmed=200977300 pending=19998800 hasSynced=true.
Turns the read-only monolithic /admin page into a real operator
console. Same functionality, but broken into focused tabs with clear
navigation between them, and a matching public-side nav bar.

Structure
---------
Public (unchanged auth: none):
  GET  /                            Overview
  GET  /blocks                      Block history
  GET  /worker/:name                Per-worker
  GET  /worker-lookup?name=X        Redirects to /worker/X (nav search box)

Admin (Basic auth on every route; router lives in lib/admin-router.js):
  GET  /admin                       Overview  (balances + health)
  GET  /admin/workers               Per-worker balance table
  GET  /admin/deposits              Deposits history + New Deposit form
  GET  /admin/payouts               Recent + in-flight + Trigger button
  GET  /admin/tools                 Nudge mine + Remove-from-mempool
  GET  /admin/worker/:id            Per-worker audit (existed, now shares nav)
  GET  /admin/logout                401 clears browser Basic-auth cache
  GET  /admin/api/summary           JSON (was /api/admin/summary)
  GET  /admin/api/worker/:id        JSON (was /api/admin/worker/:id)
  POST /admin/action/{nudge-mine,remove-from-mempool,trigger-payout,deposit}

Robustness
----------
 - Central error middleware inside the admin router — any thrown
   error becomes a red flash-redirect, never a leaked stack trace.
 - Action forms carry a hidden `return_to` so each POST redirects back
   to the page it was fired from; whitelist against a fixed set of
   admin paths prevents open-redirect abuse.
 - Writable SQLite handle moved to lib/db-admin.js; server.js opens
   two distinct handles and only the admin router receives the
   writable one, so no read path can accidentally trigger a write.
 - Security headers on every response: X-Content-Type-Options nosniff,
   X-Frame-Options DENY, Referrer-Policy same-origin,
   Permissions-Policy interest-cohort=().
 - Fmt helpers (fmtN, fmtTs, ago, fmtSats, fmtPct) centralised in
   lib/fmt.js and dropped onto res.locals via middleware — views no
   longer duplicate the same 15-line preamble.

Shared partials (views/partial/):
   head.ejs         — <head> + optional refresh + title
   nav-public.ejs   — public top nav + worker-name search box
   nav-admin.ejs    — admin top nav (5 tabs + Log out), orange bar
   flash.ejs        — green/red banner after any action redirect

CSS additions (public/style.css):
   .top-admin (distinct color for admin surface)
   .topnav / .topsearch / .logout / .flash-{ok,err}

Old dashboard/views/admin.ejs (381 lines) removed — its sections are
distributed across the five new admin-*.ejs views.

Not changed
-----------
 - Auth scheme stays HTTP Basic (per operator ask). /admin/logout is
   the only new user-visible endpoint that touches auth state.
 - No JSON API refactor / SPA — every page is still server-rendered EJS.
 - Public routes and their JSON APIs (/api/*) unchanged.

Smoke tested locally: all 7 admin routes render, security headers
present, POST actions carry the return_to through the whitelist,
/admin/logout returns 401 with a valid ASCII WWW-Authenticate header.
@rsantacroce
rsantacroce merged commit 846fc77 into LayerTwo-Labs:main Jul 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant